「請問你們的專案有單元測試嗎?」
面試中如果你提出這個問題,可能會讓面試官面有難色。
測試的重要性,大部分開發者都心知肚明。只是願意認真對待的人未必很多。
但如果真心想提高程式碼品質、減少 bug,讓專案更容易維護,那單元測試依舊是不可或缺的工具。
良好的測試不僅能幫助我們及早發現問題,還能在專案重構或新增功能時,確保現有的功能不會被破壞。
雖然寫測試會增加初期的開發時間,而且維護上也需要花費心力——這本來就不是一件輕鬆的事。但長期而言,它能為專案帶來持續的健全與穩定性。
所以,我們還是好好寫測試吧!
這是整個系列中唯一一篇有全文大綱的教學。
原因是,本文要提及的事項較多,畢竟單元測試這麼大的主題,怎麼可能靠一篇 2500 字的文章說完。限於篇幅,無法一一詳談——但也不能直接省略。
所以需要有一個供讀者鳥瞰的全文輪廓,讓你更容易了解、吸收。大綱如下:
簡單來說,本文不會講解所有的程式碼改動,而是在必要時提及。其餘部分,由我直接實作並收錄在範例專案中,讓讀者自行參考。
在有限的篇幅中,帶你了解整體概念比關注細節更重要。當你掌握了基本概念,再去看程式碼會更加得心應手。
有關單元測試的更多討論,歡迎參考這篇心得〈《Python 工匠》筆記(二)對「單元測試」的看法與建議〉。這是一本立論紮實的好書,相信你會有所收獲。
本文所有的程式碼改動,可參考這個 PR。
我覺得,討論單元測試,就必須先直面現實。
在軟體測試領域,充斥著各種關於測試的狂熱與教條主義,有時反而讓人卻步。
理論上,撰寫單元測試應該是每位開發者都要做的事(我確實是這麼想的) 。
此外,還有「測試驅動開發(TDD)」的理念,這是一種以測試為主導的開發模式,要求在撰寫功能程式碼之前,先撰寫測試。
甚至有少部分人認為,測試覆蓋率就是要 100%。因為如果不是 100%,比如 70%,那我們就可以問:「為什麼不是其它數字?」
然而,現實中,我們很少能看到理想的測試——甚至常常沒有測試。
現實中的專案因為時間、資源等諸多限制,往往不願投入心力去撰寫測試。
一些老舊專案,由於前期沒有測試基礎,後續要再補上測試變得更加困難(畢竟都亂成一團💩了),這就是我們常說的「技術債」。
再者,過度的「測試理想主義」有時也會讓初學者望而卻步。許多新手在接觸測試時,會擔心自己無法達到 100% 的覆蓋率,因此對測試產生了抗拒或懷疑。
這樣的完美主義往往有害無利,我們需要在理想與現實之間找到一個折衷。
在實際開發中,我們應秉持著一個實用且可行的測試策略,重點放在測試專案中最核心的功能,例如 API 的呼叫與 200 回應。
多數情況下,只要能覆蓋 60-70% 功能,就已經能明顯提高專案程式碼的品質,並為後續開發提供一定的安全感——這真的很重要。
不必追求完美的測試覆蓋率,只要願意開始行動,測試就能發揮它應有的價值。
回到專案本身。
雖然本文無法提及太多 API 單元測試的具體細節,但重要的概念仍不可略過。以下一一說明。
Test client 對 API 的測試至關重要,因為它能模擬真實的 HTTP 請求——注意,只是模擬。
API 測試和一般的程式碼測試略有不同,一般的測試,只要寫好相關的測試函式、邏輯並執行即可。但在 API 測試中,還需要一個「假的客戶端」來模擬請求的發送。
手動測試 API,我們通常會使用 API client,比如 Postman。而自動化的單元測試,則需要把這個「假的客戶端」直接寫在測試程式碼中——即 test client。
它相當於一個「專案內部的 API client」,而且能自動執行。
Django Ninja 有提供自己的 test client,但我建議你先不要用,因為它還不夠健全。
在範例專案中,我使用的是 Django 內建的 test client——歷史悠久、穩定可靠。
pytest(對,它的 p 是小寫,同 pyenv)是一個廣受歡迎的 Python 測試框架,擁有自己的生態系——包含大量實用的外掛。
相較於 Python 內建的unittest
模組,pytest 的語法更直觀、使用上的靈活性更好。尤其是它的 fixtures、參數化測試等功能,讓測試的撰寫更加簡單、高效。
pytest-django 是一個專為 Django 設計的 pytest 整合套件。它提供了豐富的 Django 整合功能,包括許多內建的 fixtures 和實用的裝飾器。
其中又以@pytest.mark.django_db
裝飾器最常用,它能自動管理測試過程中的資料庫狀態。
它讓 pytest 在每次測試執行前自動建立一個全新的資料庫,並在測試結束後刪除。這確保每次測試的環境一致,防止資料殘留導致的測試結果不準確。
Fixtures 是 pytest 提供的一種機制,用來設定測試所需的初始環境。它們本質上是函式,但用法卻不像一般的函式。只要事先定義好,即可在測試函式中作為參數引用。
Fixtures 可以定義在 Django app 的tests.py
中,但我們通常將它們放在可供全專案共用的conftest.py
模組。
測試 API 時,我們經常需要一些初始資料,例如使用者、產品等。這些資料可以透過 fixtures 自動產生,無需每次手動重建。
如此一來,撰寫測試的效率提高,還避免了重複的狀態設定。
本篇我實作 3 個 fixture 和 3 個測試函式,它們都與 user 有關,容我擇要解說其中的細節。
這是專案的 3 個 fixture,定義在conftest.py
中:(為減少篇幅我省略了 docstring)
import pytest
from django.test import Client
...
@pytest.fixture(scope='session')
def client() -> Client:
return Client()
@pytest.fixture
def user() -> User:
return User.objects.create_user(
username='testuser',
email='testuser@example.com',
password='testpassword123'
)
@pytest.fixture
def authenticated_client(client: Client, user: User) -> Client:
response = client.post(
'/users/login/',
{'username': 'testuser', 'password': 'testpassword123'},
content_type='application/json',
)
assert response.status_code == 200
# 設定登入後的 cookies
client.cookies.update(response.cookies)
return client
在這段程式碼中:
client
:提供了一個可以用來模擬發送請求的 Django test client。(未認證)user
:自動建立一個測試用的使用者,供測試函式甚至其它 fixture 引用。authenticated_client
:引用上述的 2 個 fixture,組合並模擬了一個登入過的 client,這樣才能測試那些有「認證保護」的 API。Fixtures 的定義、組合與使用,是 pytest 的一大特色。
不僅能簡化測試的環境設定,還能提高測試程式碼的可讀性——把測試狀態和測試邏輯分開,這也是一種「關注點分離」。
在實際的測試函式中,我們只需要將所需的 fixtures 作為參數傳入,pytest 會自動處理它們的初始化和清理工作。
這種設計大大減少了重複程式碼,讓測試更加專注於 API 的邏輯驗證而非環境設定。
最後是測試函式,我們看其中的兩個就好:(我省略了參數的 type hints,讓你聚焦於 fixtures 本身)
def test_get_users(authenticated_client) -> None:
"""
測試取得所有使用者
"""
response = authenticated_client.get('/users/')
assert response.status_code == 200
def test_login_user(client, user) -> None:
"""
測試登入使用者
"""
response = client.post(
'/users/login/',
data={'username': 'testuser',
'password': 'testpassword123'},
content_type='application/json',
)
assert response.status_code == 200
選擇這兩個函式是有教學用意的:
test_login_user
函式測試「使用者登入」API,該 API 是給「未登入」的用戶存取,所以引用一般的 client(未認證)即可。
test_get_users
測試的是「有認證保護」的 API,需要登入才能存取,所以我們引用了authenticated_client
。
該「引用」哪些 fixture,就看各函式需要什麼樣的測試狀態與條件。
Fixtures 本身可以重複使用,這樣的設計讓測試本身也非常「模組化」——這是 pytest 如此受歡迎的原因之一。
最後,來跑一下測試!
你可以在專案的根目錄直接使用pytest
指令,或透過 VS Code 的 Testing UI 來執行單元測試:
Beautiful!
理想與現實總有差距,透過務實的測試策略,我們可以在不過度追求完美的前提下,為專案提供足夠的品質保證。
Test client 和 pytest 等工具,讓 API 測試變得簡單、有條理。測試覆蓋率不必是百分之百,只要能達到一定水準,就可以為開發過程帶來巨大的助力。
本系列教學已接近尾聲。我們探討了 Django Ninja 的核心功能與進階特性——從路由設計到單元測試。這是一個辛苦但充實的過程——無論對你我而言。
下一篇,也就是最後一篇。我們要簡單回顧整個系列,並分享我在本次鐵人賽的創作與完賽心得。
本文同步發表於我的部落格——Code and Me
最近也在認真學寫測試,覺得 Mock 其實非常有趣! 寫測試都快寫出興趣了
當測試真正幫到你時候,你才會知道寫測試有多好XD
當測試真正幫到你時候,你才會知道寫測試有多好XD
完全認同
不過話說回來,我都還沒認真研究過 Mock(掩面逃走)
如果是回傳固定結果的 API ,也許直接 call 也無所謂,頂多就是測試速度會比較慢。
我自己主要是會用到 call LLM 的 API,這種 API 每次回傳結果都不一樣,如果不用 Mock 感覺會很難寫一個穩定的測試